Skip to content

feat(emails): send cc, bcc and attachments - #325

Open
Silthus wants to merge 7 commits into
feat/emails-sendfrom
feat/emails-attachments
Open

Silthus wants to merge 7 commits into
feat/emails-sendfrom
feat/emails-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown

💡 Motivation and Context

Ruby apps that send transactional email through PostHog can't copy anyone in or attach a file: client.emails.send from #324 takes one recipient and no files. An invoice, a receipt or a ticket export has to go out through another provider. posthog-node and posthog-python now take cc, bcc and attachments (PostHog/posthog-js#5514, PostHog/posthog-python#1172), and the send route accepts them (PostHog/posthog#127988). This PR adds the same three fields to posthog-ruby.

posthog.emails.send(
  to: 'user@example.com', template_key: 'invoice', subject: 'Your invoice', html: rendered_html,
  cc: 'billing@example.com', bcc: ['audit@example.com'],
  attachments: [
    { filename: 'invoice.pdf', content: File.binread('invoice.pdf') },
    { filename: 'logo.png', content: File.binread('logo.png'), content_type: 'image/png', content_id: 'logo' }
  ]
)

Stacked on #324; review that one first.

Important

Release this only after PostHog/posthog#127988 is deployed. Until then the send route refuses a send that has any of the three fields, and names the field.

What it does

  • cc and bcc: one address or a list, like reply_to. An empty list is left out of the request.
  • attachments: a list of Hashes with filename, content, and optionally content_type and content_id. A content_id shows the file inline, referenced from the HTML as cid:<id>. Keys can be Symbols or Strings. An empty list is left out.
  • Content: Ruby has one String type for text and bytes, so the client decides by encoding:
    • a binary String, as File.binread returns, is the file's bytes, and the client encodes it as Base64;
    • a list of byte values, as Resend's Ruby SDK takes (File.binread(path).bytes), is encoded the same way;
    • any other String must already be Base64. Base64 with line breaks is taken only in the exact layout Base64.encode64 writes, and sent without them; other text with line breaks is refused, so a text file read with File.read never goes out garbled. Text that isn't ASCII, such as a PDF read with File.read, can't be Base64 and is refused with a hint to use File.binread.
      Encoding happens once, before the first attempt, so retries send the same body.
  • Refused before any request, with PostHog::EmailError code invalid_attachment, name validation_error, and a field such as attachments__0__content, as in Node and Python:
    • an attachment given by path (the client reads no files);
    • an unknown key, a missing filename or content, or text that isn't Base64 (including text that isn't ASCII).
      The message never holds the file's content.
  • Refused by PostHog, as PostHog::EmailError invalid_request with the field and the server's message, why and fix: blocked file types, unsafe filenames or content IDs, more than 10 addresses per field or 10 files, and more than 512 KB decoded in total.
  • The template form (template_id) refuses all three fields, as it does subject and html.

Impact

Pays into the Workflows team's growth goal for Messaging (email is in scope) through transactional email sends from server SDKs. It removes a reason a Ruby app keeps a second email provider: invoices, receipts and exports need attachments. Expected move: small, up, once released. Run cost: n/a, same send route. Measure: sends with attachments or copies by SDK, not measured yet; it needs the same User-Agent attribution as #324.

Merge Danger

Low, two-way door until release. The change is additive: the three keywords are new and optional, and a send without them builds the same request as before. Releasing the gem makes the shape public, so the content rule (binary String, byte list, or Base64 text) should settle in review. Releasing before the server deploys would make sends with these fields fail with the server naming the field.

💚 How did you test it?

  • Lane gate on df3ff92: bundle exec rspec spec/posthog/emails_spec.rb 71 examples, three runs in random order, 0 failures; the full suite, 2090 examples, 0 failures. bundle exec rubocop: 146 files, no offenses. bundle exec rake public_api:check passes.
  • CI on df3ff92: 20 checks green.
  • Test seam: spec/posthog/emails_spec.rb, describe 'copies and attachments', drives Client.new(...).emails.send against WebMock at the HTTP boundary: the wire body for every content form, Symbol and String keys, empty lists left out, one Base64 encoding shared by every retry, each client-side refusal with its field, and the template form refusing the fields.
  • Live send on df3ff92 through a local PostHog stack: Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988 (on #127123), cdp-api and the email and hogflow CDP workers at #127986 (on #127995 and #127985), the email flags on, and maildev as the SMTP server. A proxy between the Ruby client and Django records each request and response. Every case calls client.emails.send:
    1. to, two cc, one bcc, a PDF from File.binread and an inline PNG with content_id: 'logo': SendResult with an id. maildev holds one message, with to and both cc in the headers and the bcc address only in the envelope (no Bcc header). Both files arrive byte for byte (sha256), and the PNG is inline with Content-ID: <logo>, which the HTML's cid:logo uses.
    2. A byte list (File.binread(path).bytes) and an already-encoded Base64 string: delivered, both files byte for byte.
    3. An attachment given by path: PostHog::EmailError invalid_attachment, field attachments__0__path. No request reaches PostHog, and no mail arrives.
    4. An .exe: HTTP 400, EmailError invalid_request, field attachments__0__filename, "Mailbox providers block .exe files.", with the server's why and fix. No mail.
    5. Two files of 300 KB: HTTP 400, EmailError invalid_request, field attachments, "Send at most 512 KB of attachments in total, counted after decoding." No mail.
    6. Empty cc, bcc and attachments lists: the request body has none of the three keys, and the email is delivered.
  • Cross-version on df3ff92: bodies from feat(emails): send transactional email and read its status #324's SDK and from this PR go through the real SendEmailSerializer of the server before these fields (213fad6, the base of feat(messaging): send cc, bcc and attachments on a code send posthog#127988) and after (#127988). feat(emails): send transactional email and read its status #324's sends are accepted by the new server. This PR's plain sends and sends with empty lists are accepted by the old server. A send with cc, bcc or attachments is refused there, naming the field, rather than going out without it.
  • Contract test: the same six cases' request bodies also go through code_send_fields.py from #127988 on the Django and DRF versions the server pins. 15 of 15 checks pass.
  • Gaps: none.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. The YARD docs on Emails#send describe the three keywords.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file. posthog-ruby minor.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code (Claude Opus 5.5) as one slice of a map that brings every server SDK to parity on emails.send. The shape follows posthog-node and posthog-python.

Question Answer Who Where
Do the SDKs take cc, bcc and attachments? Yes, every SDK at parity with Node and Python 👤 Human Silthus/posthog#461
How does Ruby tell file bytes from Base64 text? A binary String or a byte list is bytes and gets encoded; any other String must be Base64, else invalid_attachment 🤖 Agent comment
What does an attachment the client can't send raise? PostHog::EmailError invalid_attachment with a field, as Node and Python do, instead of ArgumentError 🤖 Agent comment

Involvement: no grilling rounds, no prototypes; QA swarm round 1 (nine lenses, including the team's michael and sdk-spec-conformance perspectives) and round 2 on the fixes, with fixes in a3a1aab and e29b8a7, then two clean final reads, the second on df3ff92; live send, cross-version and contract proofs captured.

🤖 Generated with Claude Code

Silthus and others added 2 commits November 4, 2026 06:59
The code form takes cc and bcc (one address or a list) and attachments:
Hashes with filename, content, and an optional content_type and
content_id. A binary String, such as File.binread returns, or a list of
byte values is encoded as Base64 once, before the retry loop; any other
String is taken as Base64 already and must look like it. Empty lists
are left out, so a PostHog version without the fields still sends.
A file the client can't send raises EmailError invalid_attachment with
the field, before any request. Mirrors posthog-node (posthog-js#5514)
and posthog-python#1172; the server holds the limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-04T09:18:18.925150+00:00
Duration: 55706ms

⚠️ Some Tests Failed

39/47 tests passed, 8 failed


Capture Tests

⚠️ 23/30 tests passed, 7 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 8ms
Format Validation.Event Has Uuid ✅ 5ms
Format Validation.Event Has Lib Properties ✅ 5ms
Format Validation.Distinct Id Is String ✅ 8ms
Format Validation.Token Is Present ✅ 6ms
Format Validation.Custom Properties Preserved ✅ 6ms
Format Validation.Event Has Timestamp ✅ 7ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 6ms
Retry Behavior.Retries On 503 ✅ 5265ms
Retry Behavior.Does Not Retry On 400 ❌ 5ms
Retry Behavior.Does Not Retry On 401 ❌ 6ms
Retry Behavior.Respects Retry After Header ✅ 8012ms
Retry Behavior.Implements Backoff ❌ 247ms
Retry Behavior.Retries On 500 ✅ 5156ms
Retry Behavior.Retries On 502 ✅ 5161ms
Retry Behavior.Retries On 504 ✅ 5117ms
Retry Behavior.Max Retries Respected ❌ 407ms
Deduplication.Generates Unique Uuids ✅ 14ms
Deduplication.Preserves Uuid On Retry ✅ 5110ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10285ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5118ms
Deduplication.No Duplicate Events In Batch ✅ 14ms
Deduplication.Different Events Have Different Uuids ✅ 8ms
Compression.Sends Gzip When Enabled ✅ 4ms
Batch Format.Uses Proper Batch Structure ✅ 5ms
Batch Format.Flush With No Events Sends Nothing ✅ 3ms
Batch Format.Multiple Events Batched Together ❌ 12ms
Error Handling.Does Not Retry On 403 ❌ 4ms
Error Handling.Does Not Retry On 413 ❌ 4ms
Error Handling.Retries On 408 ✅ 5113ms

Failures

retry_behavior.does_not_retry_on_400

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.does_not_retry_on_401

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.implements_backoff

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.max_retries_respected

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

batch_format.multiple_events_batched_together

Expected 1 requests, got 5

error_handling.does_not_retry_on_403

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

error_handling.does_not_retry_on_413

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 8ms
Request Payload.Flags Request Uses V2 Query Param ✅ 7ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 7ms
Request Payload.Flags Request Omits Authorization Header ✅ 6ms
Request Payload.Token In Flags Body Matches Init ✅ 6ms
Request Payload.Groups Round Trip ✅ 5ms
Request Payload.Groups Default To Empty Object ✅ 5ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 5ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 5ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 5ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 5ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 7ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 5ms
Retry Behavior.Retries Flags On 502 ✅ 136ms
Retry Behavior.Retries Flags On 504 ✅ 142ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 8ms

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 1a2acc7: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread lib/posthog/emails.rb Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-04T09:18:51.595251+00:00
Duration: 98355ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

✅ 30/30 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 9ms
Format Validation.Event Has Uuid ✅ 105ms
Format Validation.Event Has Lib Properties ✅ 107ms
Format Validation.Distinct Id Is String ✅ 106ms
Format Validation.Token Is Present ✅ 106ms
Format Validation.Custom Properties Preserved ✅ 107ms
Format Validation.Event Has Timestamp ✅ 105ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 7ms
Retry Behavior.Retries On 503 ✅ 5409ms
Retry Behavior.Does Not Retry On 400 ✅ 2110ms
Retry Behavior.Does Not Retry On 401 ✅ 2108ms
Retry Behavior.Respects Retry After Header ✅ 8114ms
Retry Behavior.Implements Backoff ✅ 15619ms
Retry Behavior.Retries On 500 ✅ 5212ms
Retry Behavior.Retries On 502 ✅ 5209ms
Retry Behavior.Retries On 504 ✅ 5212ms
Retry Behavior.Max Retries Respected ✅ 15618ms
Deduplication.Generates Unique Uuids ✅ 111ms
Deduplication.Preserves Uuid On Retry ✅ 5210ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10316ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5215ms
Deduplication.No Duplicate Events In Batch ✅ 111ms
Deduplication.Different Events Have Different Uuids ✅ 107ms
Compression.Sends Gzip When Enabled ✅ 106ms
Batch Format.Uses Proper Batch Structure ✅ 106ms
Batch Format.Flush With No Events Sends Nothing ✅ 4ms
Batch Format.Multiple Events Batched Together ✅ 111ms
Error Handling.Does Not Retry On 403 ✅ 2108ms
Error Handling.Does Not Retry On 413 ✅ 2107ms
Error Handling.Retries On 408 ✅ 5209ms

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 107ms
Request Payload.Flags Request Uses V2 Query Param ✅ 107ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 106ms
Request Payload.Flags Request Omits Authorization Header ✅ 106ms
Request Payload.Token In Flags Body Matches Init ✅ 106ms
Request Payload.Groups Round Trip ✅ 107ms
Request Payload.Groups Default To Empty Object ✅ 106ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 106ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 105ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 106ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 105ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 111ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 108ms
Retry Behavior.Retries Flags On 502 ✅ 258ms
Retry Behavior.Retries Flags On 504 ✅ 207ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 108ms

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

@Silthus

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 How does Ruby tell file bytes from Base64 text? Ruby has one String type for both, so the encoding decides. A binary String (File.binread) or a list of byte values (Resend's Ruby style, File.binread(path).bytes) is the file's bytes, and the client encodes it. Any other String must already be Base64; text that isn't raises invalid_attachment naming the field. The alternative, a separate content_base64: key, would differ from Node, Python and the server's field names.
  • 🤖 What does an attachment the client can't send raise? PostHog::EmailError with code invalid_attachment (name validation_error) and a field such as attachments__0__content, as Node and Python do, instead of the ArgumentError other bad arguments raise. The caller handles a refused file the same way whether the client or PostHog refused it.
  • 🤖 Is a contract test enough in place of the live send? No longer needed. The live send ran through a local PostHog stack (Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988, the CDP chain to #127986, maildev) at df3ff92 and passed every case. The contract test stays as extra evidence.

Silthus and others added 3 commits November 4, 2026 07:24
…pped Base64

- A String that isn't ASCII, such as a PDF read with File.read or UTF-16
  text, raised a bare ArgumentError from the Base64 match. It now raises
  invalid_attachment with the field and the File.binread hint.
- Base64 with line breaks, as Base64.encode64 writes it, is sent without
  them instead of being refused.
- The retry spec checks both attempts carry the encoded file, and the
  Base64Check helper is inlined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing every line break let a text file read with File.read, such as
"ABCD1234\nEFGH5678\n", pass as Base64, so the recipient got a garbled
file. Line breaks are now removed only when the content is exactly what
Base64.encode64 writes; other text is refused as invalid_attachment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 4, 2026 09:48
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 09:48
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed df3ff92: no findings.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved — this change needs a human reviewer.

Re-adding the stamphog label gives the same result unless the changed files change.

stamphog declined this pull request because two gates failed. The deny-list gate matched public_api: the change edits public_api_snapshot.txt and adds cc:, bcc: and attachments: to the public signature of PostHog::Emails#send in lib/posthog/emails.rb. The tier gate classified it as T2-never (230 lines, 5 files, cross-cutting feature), which a bot can't clear. The size and prerequisites gates passed, but they don't change the outcome.

The author should ask a human reviewer, such as a maintainer who owns the emails SDK, to review this pull request. The changes to the public API surface and the attachment content rules need that kind of review.

  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: public_api
size ✓ 109L, 2F substantive, 230L/5F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (230L, 5F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head df3ff92

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant